Space Python Client
Release 1.1.0
This minor release preserves existing public methods and adds subscription reads, contract events,
multiorganization token claims, event deduplication and JWKS verification. Backend SDKs also
provide pricing YAML reads, novation previews, optimistic concurrency and opt-in idempotent retries.
Use a SPACE server advertising the required X-Space-Capabilities; merging server changes alone
does not deploy them. Read userId through the provided helper: sub is opaque.
Detailed consumption reversions
features.revert_consumption_details(user_id, feature_id, consumption_id, idempotency_key=None) preserves the server's refund details. The existing boolean
revertConsumption / revert_consumption remains available and unchanged.
| SPACE response | Detailed result |
|---|---|
| 204 | Found; nothing omitted. No per-limit amounts were returned (reverted is absent/null). |
| 200 | Found; reverted contains amounts and skipped identifies limits which no longer exist. |
| 404 | Not found; nothing was changed by this request. |
| 409 | An API error is propagated, including a disabled service or IDEMPOTENCY_STATE_CHANGED; no automatic retry. |
The result is ConsumptionRevertResult: consumption ID, found, optional reverted amounts and
skipped limit IDs (Python uses consumption_id; Java uses getters and isFound()). To retry one
operation across process restarts, store and reuse its idempotency key. A new key means a new operation.
A successful signature still requires the application to match the token's user to its authenticated user.
Python client library for Space, a pricing-driven self-adaptation platform for SaaS applications.
Table of Contents
- What You Get
- Requirements
- Installation
- Quick Start in 5 Minutes
- Async Client
- Configuration
- API Overview
- Data Models
- Caching
- WebSocket Events
- Pricing Tokens
- Consumption, retries and Idempotency-Key
- Error Handling
- Testing
- Contributing
- License
What You Get
- Simple API to connect to Space.
- An asynchronous client for services that run in an event loop.
- Contract lifecycle operations.
- Feature evaluation with optional expected consumption.
- Revert operation for optimistic usage updates.
- Pricing token generation.
- Pricing reads for pricing pages: the raw Pricing2Yaml of a version, the versions of a user's contract, and the version new contracts get.
- Built-in in-memory cache and optional Redis cache.
- WebSocket events for real-time pricing updates, and contract events for the API key's organisation.
Requirements
- Python 3.10+
Installation
pip install space-python-client
Optional Redis support:
pip install "space-python-client[redis]"
Optional verification of pricing tokens (PyJWT with cryptography, see
Verifying pricing tokens):
pip install "space-python-client[verify]"
Quick Start in 5 Minutes
from space_client import SpaceClientFactory
client = SpaceClientFactory.connect(
"http://localhost:3000",
"your-api-key",
10_000,
)
healthy = client.is_connected_to_space()
print(f"Connected: {healthy}")
result = client.features.evaluate("user-123", "serviceA-featureX")
if result.error is not None:
print(f"Evaluation error: {result.error.message}")
else:
print(f"Feature enabled: {result.eval}")
pricing_token = client.features.generate_user_pricing_token("user-123")
print(f"Pricing token: {pricing_token}")
client.close()
Async Client
Entitlement checks sit on the request path: a service asks Space "may this user do this?" before serving a request. Made synchronously from inside an event loop, that call blocks every other request the process is serving for as long as it takes - including the full timeout, when Space is slow or unreachable.
AsyncSpaceClient is the same client over httpx.AsyncClient. Same modules,
same method names, same arguments, same exceptions; the calls are awaited.
from space_client import SpaceClientFactory
client = SpaceClientFactory.connect_async(
"http://localhost:3000",
"your-api-key",
10_000,
)
async with client as space:
result = await space.features.evaluate("user-123", "serviceA-featureX")
print(f"Feature enabled: {result.eval}")
token = await space.features.generate_user_pricing_token("user-123")
print(f"Pricing token: {token}")
Two differences, both consequences of running in a loop:
- The WebSocket is not opened by the constructor, because a constructor
cannot await. Call
await client.connect(), or use the client as an async context manager as above, when you want pricing events. HTTP calls work without it. - Closing is awaited:
await client.close(), or leave theasync with.
Everything else - cache lookups and invalidation, request building, error handling - is shared code rather than a second copy, so the two clients cannot drift apart.
The cache providers remain synchronous. An in-memory lookup is not worth an await; a Redis one is a short local round trip, so if you are caching in Redis and want nothing at all blocking the loop, that is worth knowing.
Configuration
You can connect using either convenience factory arguments or SpaceConnectionOptions.
Option A: simple connection
from space_client import SpaceClientFactory
client = SpaceClientFactory.connect("http://localhost:3000", "your-api-key")
Option B: custom timeout
client = SpaceClientFactory.connect("http://localhost:3000", "your-api-key", 15000)
Option C: full options object
from space_client import SpaceClientFactory
from space_client.types import CacheOptions, SpaceConnectionOptions
options = SpaceConnectionOptions(
url="http://localhost:3000",
api_key="your-api-key",
timeout=15000,
cache=CacheOptions(enabled=True),
)
client = SpaceClientFactory.connect(options)
SpaceClientFactory.connect(...) validation rules:
- URL is required and must start with
http://orhttps://. - API key is required.
- Timeout must be a positive number.
API Overview
SpaceClientFactory
| Method | Description |
|---|---|
connect(options) | Creates a client from full options and validates required inputs. |
connect(url, api_key) | Convenience overload with default timeout (5000). |
connect(url, api_key, timeout) | Convenience overload with custom timeout. |
connect_async(...) | Same arguments and same validation, returning an AsyncSpaceClient. |
SpaceClient
Main entry point. Exposes modules as public fields:
contractsfeaturespricingsservicescache
Core methods:
| Method | Returns | Details |
|---|---|---|
is_connected_to_space() | bool | HTTP health check against /healthcheck. |
has_capability(token) | bool | Whether Space advertises an X-Space-Capabilities token (idempotency-key, atomic-usage-limits...), from a recent response or GET /healthcheck. |
on(event, callback) | None | Registers event callback for supported event names. |
remove_listener(event) | None | Removes one callback by event name. |
remove_all_listeners() | None | Removes all event callbacks. |
connect() | None | Connects/reconnects WebSocket namespace if disconnected. |
disconnect() | None | Disconnects WebSocket namespace. |
close() | None | Closes sockets, cache provider, and HTTP resources. |
Java-style aliases are also available (isConnectedToSpace, removeListener, removeAllListeners, etc.).
AsyncSpaceClient
The same modules and the same methods, awaited. is_connected_to_space(),
connect(), disconnect() and close() are coroutines; on,
remove_listener and remove_all_listeners are not, since they only touch a
local registry. Supports async with.
ContractModule
| Method | Returns |
|---|---|
get_contract(user_id) | `Contract |
add_contract(contract_to_create, idempotency_key=None) | `Contract |
update_contract_subscription(user_id, new_subscription, expected_contract_version=<unset>, keep_billing_period=False, reject_private=False, idempotency_key=None) | `Contract |
update_contract_subscription_by_group_id(group_id, new_subscription, idempotency_key=None) | `list[Contract] |
update_contract_usage_levels(user_id, service_name, usage_levels_novations, idempotency_key=None) | `Contract |
remove_contract(user_id) | None |
get_pricings(user_id) | ContractPricings |
preview_novation(user_id, new_subscription, keep_billing_period=False, reject_private=False) | NovationPreview |
Java-style aliases are also available (getContract, addContract, etc.).
PricingModule
| Method | Returns |
|---|---|
get_yaml(service_name, pricing_version, etag=None) | PricingYaml |
ServiceModule
| Method | Returns |
|---|---|
get_new_contracts_pricing(service_name) | NewContractsPricing |
Pricing pages
A host backend renders a pricing page with the version a visitor would get, or with the version a signed-in customer is on:
# Signed-out visitor: the version SPACE assigns to new contracts.
current = client.services.get_new_contracts_pricing("acme")
pricing = client.pricings.get_yaml("acme", current.version)
print(current.source) # "sphere-policy" | "default" | "latest-active"
# Signed-in customer: the version of THEIR contract.
mine = client.contracts.get_pricings("user-123")
acme = mine.services["acme"]
print(acme.version, acme.status, acme.plan, acme.add_ons, acme.new_contracts_version)
pricing = client.pricings.get_yaml("acme", acme.version)
# The file is returned byte for byte; keep its ETag to revalidate cheaply.
again = client.pricings.get_yaml("acme", acme.version, etag=pricing.etag)
if again.not_modified:
... # reuse pricing.yaml
Changing a subscription safely
Preview first, then apply with the contract_version the preview was computed
on. If anything changed the contract in between, Space refuses with 412 and the
client raises ContractChangedError instead of overwriting it:
from space_client import ContractChangedError, SpaceFeatureUnsupportedError
from space_client.types import NovationErrorCode, Subscription
change = Subscription(
subscription_plans={"acme": "growth"},
subscription_add_ons={"acme": {"extraStorage": 2}},
) # contracted_services left empty: keep the versions or let the policy decide
preview = client.contracts.preview_novation("user-123", change, keep_billing_period=True)
if not preview.valid:
for error in preview.errors:
if error.code == NovationErrorCode.ADDON_NOT_AVAILABLE:
...
print(error.code, error.service, error.path, error.message)
else:
print(preview.effective, preview.version_changes, preview.usage_warnings)
try:
client.contracts.update_contract_subscription(
"user-123",
preview.effective,
expected_contract_version=preview.contract_version,
keep_billing_period=True,
)
except ContractChangedError as changed:
print("Changed meanwhile; current version is", changed.contract_version)
except SpaceFeatureUnsupportedError as unsupported:
# This Space cannot honour the option (unsupported.capability).
if unsupported.written:
... # the change WAS applied without the guarantee: unsupported.contract
else:
... # nothing was written
preview_novationwrites nothing and leaves the cache alone. An invalid change is an answer (valid=False,errors), not an exception. Unknown error codes are kept as strings.expected_contract_versionis sent asIf-Match. Leave it out for an unconditional update, exactly as before. PassingNoneor""raisesValueErrorwithout sending anything: a version that was never known must not quietly turn a conditional update into an unconditional one.keep_billing_period=Truesends?keepBillingPeriod=true: history is still recorded but the billing period is not restarted.reject_private=True(on both calls) sends?rejectPrivate=true, asking Space to refuse private plans and add-ons. Rejection is opt-in: without it Space accepts them, as before.Contract.contract_versionis filled from the body or, failing that, from theETagof the update response. It isNoneon Space versions without it.
Older Space versions. A Space without optimistic concurrency ignores
If-Match, one withoutkeepBillingPeriodignores the query and restarts the billing period, and one withoutrejectPrivateaccepts private plans. So the client does not trust the options blindly. Whenexpected_contract_version,keep_billing_periodorreject_privateis set:
- Before writing it checks the
X-Space-Capabilitiesheader forcontract-version/keep-billing-period/reject-private. The header is read from any Space response and cached per client forCAPABILITIES_TTL_SECONDS(60 s;client.clear_capabilities()forgets it). When nothing recent is known, it reads the contract once. If a token is missing it raisesSpaceFeatureUnsupportedError(capability,written=False) and writes nothing. If that read'sETagalready differs fromexpected_contract_versionit raisesContractChangedErrorearly;If-Matchon the PUT is still the atomic check.- After writing it raises
SpaceFeatureUnsupportedErrorwithwritten=Trueand the storedcontractif the answer shows the option was ignored anyway: noETagwithexpected_contract_version, or a differentbillingPeriod.startDatewithkeep_billing_period. The user's cache entries are dropped.preview_novation(..., keep_billing_period=True)orreject_private=Trueraises the same error when the Space that answered does not advertisekeep-billing-period/reject-private.Without options nothing is checked and the request is the same single PUT as before.
get_yaml raises PricingYamlUnavailableError (a SpaceApiError) when Space
knows the version but no longer has its original file. get_pricings is never
served from cache: its contract_version matters only while it is current.
Older Space versions. These calls need a Space with the subscription-change API. Without it,
get_pricingsandget_new_contracts_pricingget Space's 404 (SpaceApiError). An older Space ignores?format=yamland answers with the JSON pricing;get_yamlthen raisesSpaceFeatureUnsupportedError(capability == "pricing-yaml") rather than returning JSON as if it were the file: onlyapplication/yaml,text/yamland theirx-variants are accepted.
FeatureModule
| Method | Returns |
|---|---|
evaluate(user_id, feature_id, expected_consumption=None, details=False, server=False, idempotency_key=None) | FeatureEvaluationResult (with consumption_id after a consumption) |
revert_consumption(user_id, feature_id, consumption_id, idempotency_key=None) | bool: True reverted (now or before), False unknown or expired id (404) |
revert_evaluation(user_id, feature_id, revert_to_latest=True, idempotency_key=None) | bool (deprecated, emits DeprecationWarning: use revert_consumption) |
generate_user_pricing_token(user_id) | `str |
verify_pricing_token(token, organization_id=None, issuer=None, leeway=None) | PricingTokenPayload |
Java-style aliases are also available (revertEvaluation, revertConsumption, generateUserPricingToken, verifyPricingToken).
The writes in these tables carry an Idempotency-Key; see
Consumption, retries and Idempotency-Key.
Data Models
Main model classes are exported from space_client.types:
SpaceConnectionOptionsCacheOptions,CacheType,ExternalCacheConfig,RedisConfigContract,ContractToCreate,SubscriptionPricingYaml,ContractPricings,ContractServicePricing,NewContractsPricingNovationPreview,NovationError,NovationErrorCode,NovationVersionChange,NovationUsageWarning,NovationBillingPeriodPricingTokenPayload,PricingTokenSubscription,decode_pricing_token,pricing_token_user_idFeatureEvaluationResult,EvaluationErrorBillingPeriod,BillingPeriodToCreate,ContractHistoryEntry,UsageLevel,UserContactSpaceEvent
Caching
Built-in cache is enabled with:
from space_client.types import CacheOptions, SpaceConnectionOptions
options = SpaceConnectionOptions(
url="http://localhost:3000",
api_key="your-api-key",
cache=CacheOptions(enabled=True),
)
Redis cache example:
from space_client.types import (
CacheOptions,
CacheType,
ExternalCacheConfig,
RedisConfig,
SpaceConnectionOptions,
)
options = SpaceConnectionOptions(
url="http://localhost:3000",
api_key="your-api-key",
cache=CacheOptions(
enabled=True,
type=CacheType.REDIS,
external=ExternalCacheConfig(
redis=RedisConfig(host="localhost", port=6379),
),
ttl=300,
),
)
WebSocket Events
Supported event names:
synchronizedpricing_createdpricing_archivedpricing_activedservice_disabledcontract_created,contract_novated,contract_deleted,contract_renewedresync(each time a namespace connects; see Delivery guarantees)error
Example:
def on_pricing_created(details):
print("Pricing changed:", details)
client.on("pricing_created", on_pricing_created)
client.connect()
Contract events
Contract events arrive on the authenticated /contracts namespace, and only for
contracts of the API key's organisation. The client joins it (sending the key as
Socket.IO auth: {"apiKey": ...} and as the x-api-key header) once a contract
listener is registered. A member user key (usr_...) also has to name its
organisation: pass SpaceConnectionOptions(..., organization_id="org-1") and the
handshake sends auth: {"apiKey": ..., "organizationId": "org-1"}. Organisation
keys need nothing more.
def on_novated(details):
# {"userId", "organizationId", "contractVersion", "services": [...]}
# contractVersion is a string, or None for contract_deleted.
print("Contract changed:", details["userId"], details["contractVersion"])
client.on("contract_novated", on_novated)
- The cached entries of
details["userId"]are invalidated before the callback runs, so a callback that re-reads the contract or a pricing token gets fresh data. - Register contract listeners before connecting. python-socketio joins
namespaces only when it connects, so adding the first contract listener to a
connected sync client disconnects and reconnects the socket.
/pricingsevents sent during that short window are lost, andsynchronizedfires again on reconnection.AsyncSpaceClient.oncannot await: a contract listener added afterconnect()only takes effect at the nextawait client.connect(), and the client logs a warning (loggerspace_client) to say so. - A key Space refuses on
/contractsis reported to theerrorlistener. - Removing the last contract listener does not close the namespace; its events are simply dropped until a listener is registered again.
Event ids and occurred_at
Pricing and contract listeners receive EventDetails: the details dict Space
sent, with the event's own fields as attributes.
from space_client.types import EventDetails
def on_novated(details: EventDetails):
details["userId"] # the details, as before
details.code # "CONTRACT_NOVATED"
details.event_id # "019a2a6b-7c1e-7d3f-9b2a-5c4e8f1a2b3c", the same in every copy
details.occurred_at # datetime (UTC): when Space stored the change
details.raw_occurred_at # "2026-10-27T10:00:00.123Z", as sent
Space sends eventId and occurredAt from the version that advertises
contract-event-ids in X-Space-Capabilities. With an older Space,
event_id and occurred_at are None (occurred_at is also None if the
value is not a valid date).
Delivery guarantees
Space events are hints that something changed, not a log of the changes:
- At most once per connection, no replay. An event goes to the connections open when it is emitted. While the client is disconnected or reconnecting, events are lost, and Space does not resend them.
- Repeats are possible. A consumer with several connections gets one copy
per connection, all with the same
eventId. The client drops a repeatedeventIdbefore invalidating the cache or calling your listener. It remembers the last 10,000 ids for up to 10 minutes (both namespaces share the window); events withouteventIdare always delivered. Each client has its own window, so two clients (or two replicas of your backend) each get their copy, which is what each needs to refresh its own cache. - No ordering. Events may arrive in another order than the changes were
stored. Never order events by
event_id(compare it for equality only), and do not compareoccurred_atacross Space instances, whose clocks may differ.contractVersionis a hash, not a counter.
So treat a contract event as a reason to re-read the contract
(client.contracts.get_contract(user_id)) and compare its contractVersion
with the one you hold: unchanged means nothing to do, and a 404
(SpaceApiError) means it was deleted. A late, repeated or out-of-order event then costs one read and changes
nothing.
After a reconnection, re-read the contracts you hold, because anything that
changed while the client was disconnected was not announced. The resync event
tells you when. It receives a ResyncInfo(namespace, reconnect): namespace
is "pricings" or "contracts", and reconnect is False on the
namespace's first connection and True after that. When /contracts
reconnects, the client first invalidates the cached contracts, evaluations and
pricing tokens of every user, so these reads go to Space:
from space_client.types import ResyncInfo
def on_resync(info: ResyncInfo):
if info.namespace == "contracts" and info.reconnect:
refresh_cached_contracts() # e.g. get_contract() for each user you hold
client.on("resync", on_resync)
If you read contracts before the contract listener joined /contracts,
re-read them on the first resync too. python-socketio reconnects on its own
(reconnection=True); the sync client's reconnection to add /contracts also
fires resync for pricings.
The deduplication window is configurable, or can be turned off:
from space_client.types import EventDeduplicationOptions
options = SpaceConnectionOptions(
url="http://localhost:5403",
api_key="your-api-key",
event_deduplication=EventDeduplicationOptions(max_entries=50_000, window_seconds=1800),
# or event_deduplication=False to deliver every copy
)
Pricing Tokens
features.generate_user_pricing_token(user_id) returns a JWT signed by Space.
To read it on the backend - for example to know the user's current
subscription and contract_version - decode it:
from space_client.types import decode_pricing_token
payload = decode_pricing_token(client.features.generate_user_pricing_token("user-123"))
if payload.subscription is not None: # None for tokens from older Space versions
print(payload.subscription.contracted_services)
print(payload.subscription.subscription_plans, payload.subscription.subscription_add_ons)
print(payload.subscription.contract_version)
decode_pricing_token does not verify the signature: use it to read a token
you just received from Space, never to authenticate one a client sent you.
Token claims: userId, organizationId, aud, iss
A userId is unique only within its organization, so Space versions with
per-organization userIds (capability contract-userid-per-org) issue tokens
with these claims:
| Claim | PricingTokenPayload | Value |
|---|---|---|
sub | sub | urn:space:<organizationId>:<percent-encoded userId>. Opaque: do not parse it. |
userId | user_id | The userId as stored in Space (not encoded). |
organizationId | organization_id | The organization of the contract. |
aud | aud | The organization the token is for (same value as organizationId). |
iss | iss | The Space server: its JWT_ISSUER, space by default. |
Tokens of older Space versions only have the bare userId in sub; the other
fields are then None. pricing_token_user_id(payload) returns user_id and
falls back to sub only when the token has no userId claim (an older
server); it never parses the URN. Code that used payload.sub as the userId
should switch to it:
from space_client.types import decode_pricing_token, pricing_token_user_id
payload = decode_pricing_token(token)
user_id = pricing_token_user_id(payload)
When a token is checked on the server side, also compare organization_id (or
aud) with your organization: the same userId can exist in another
organization of the same Space.
Consumption, retries and Idempotency-Key
Consumptions (evaluate with an expected_consumption), reverts, contract
creation, novations (single and by group) and usage level changes are writes
that must not be applied twice when a request is retried after a timeout or a
dropped connection. Each of these calls sends an Idempotency-Key header. On a
Space with the idempotency-key capability, a request repeated with the same
key gets the stored outcome of the first one (Idempotent-Replayed: true) and
applies nothing; an older Space ignores the header. Evaluations without a
consumption and reads carry no key.
- Generated keys. By default the client creates a random UUID v4 per call and reuses it for that call's own retries, which covers retries inside one process.
- Your own key. When the operation may be retried after the process
restarts (a job queue, an outbox, a webhook handler), create the key once,
store it with the operation and pass it on every attempt
(
idempotency_key=...). Use a new key for each new operation, and a new key after correcting a request Space refused with a4xx. With a key of your own the client first checks that Space advertisesidempotency-keyand raisesSpaceFeatureUnsupportedError(written=False, nothing sent) if it does not. A key that is not 1 to 255 characters raisesValueError. - Retries are off by default (
SpaceConnectionOptions(retries=0)). Withretries=n(0 to 10) the client retries those keyed writes, with the same key and the same request, after aSpaceConnectionError, a5xxor a409 IDEMPOTENCY_IN_PROGRESS, and only while Space advertisesidempotency-key(without it each retry could be applied again). It waitsRetry-Afterwhen Space sends one (and stops if that is longer thanretry_max_delay_ms), otherwise an exponential backoff with jitter fromretry_base_delay_ms(250 ms) up toretry_max_delay_ms(10 s). Other4xxand requests without a key are never retried.
from space_client import IdempotencyInProgressError, SpaceClientFactory
from space_client.types import SpaceConnectionOptions
client = SpaceClientFactory.connect(
SpaceConnectionOptions(url="http://localhost:3000", api_key="your-api-key", retries=2)
)
# job.idempotency_key = str(uuid.uuid4()), created and stored when the job was enqueued.
result = client.features.evaluate(
job.user_id, "acme-exports", {"acme-maxExports": 1}, idempotency_key=job.idempotency_key
)
if not result.eval:
return # over the limit: nothing was consumed
try:
run_export(job)
except Exception:
# Give back exactly this consumption, whatever happened meanwhile.
client.features.revert_consumption(
job.user_id, "acme-exports", result.consumption_id,
idempotency_key=f"{job.idempotency_key}:revert",
)
raise
With atomic-usage-limits, Space applies a consumption only if every amount
fits its limit, also under concurrency; one that does not fit answers
eval=False and consumes nothing. A consumption_id stays revertible for
Space's CONSUMPTION_RETENTION_SECONDS (24 h by default), and keys are
remembered for IDEMPOTENCY_KEY_TTL_SECONDS (24 h by default).
Verifying pricing tokens
Space versions with the capability pricing-token-jwks sign pricing tokens
with ES256 and publish the public keys at
GET {url}/api/v1/.well-known/jwks.json (no API key). A token that comes back
to your backend (from a browser, a queue, another service) can then be verified
with public material only. It needs the verify extra.
payload = client.features.verify_pricing_token(token, organization_id="my-org-id")
user_id = pricing_token_user_id(payload)
or, without a client:
from space_client import PricingTokenVerifier
verifier = PricingTokenVerifier("https://space.example.com", organization_id="my-org-id")
payload = verifier.verify(token)
AsyncPricingTokenVerifier takes the same arguments and its verify is
awaited; AsyncSpaceClient.features.verify_pricing_token is awaited too.
What is checked:
- The signature is ES256 by a key of the JWKS, chosen by the token's
kid. Any otheralg(HS256,none, ...) is rejected, and so is a token with ajwk,jku,x5uorx5cheader: keys only come from the JWKS URL of your Space. expis required and checked (leeway, in seconds, default 0).issequalsissuer(theJWT_ISSUERof your Space,spaceby default).audandorganizationIdequal the expected organization. It is required: passorganization_idto the call or to the verifier; the client uses its ownorganization_idoption when the call names none. A userId is only unique within an organization, so a valid signature alone does not tell you the token is for one of your users.
The JWKS is cached for the max-age of its response (Space sends 5 minutes;
5 minutes also without Cache-Control, nothing with no-store/no-cache) and
fetched again when a token names a kid it does not know, at most once per
refetch_cooldown (default 30 s), so key rotations need nothing on your side.
Keep one verifier (or one client) and reuse it.
Errors, all PricingTokenVerificationError (a SpaceError) with a code:
PricingTokenSignatureError (forged or tampered token),
PricingTokenExpiredError (expired_at), PricingTokenAudienceError (another
organization), PricingTokenIssuerError, PricingTokenUnknownKeyError (kid
not published, e.g. a removed key), and the base class with
PRICING_TOKEN_INVALID (malformed token, wrong alg, forbidden header,
missing claims) or PRICING_TOKEN_JWKS_UNAVAILABLE (the JWKS could not be
fetched). A Space that does not publish its keys (older versions, or one in the
deprecated PRICING_TOKEN_ALG=HS256 mode) raises SpaceFeatureUnsupportedError
with capability == "pricing-token-jwks".
Error Handling
-
Factory validation raises
ValueErrorfor invalid input. -
A request Space refused raises
SpaceApiError, carrying the status code, the method and path, and whatever body came back. -
A request that never arrived raises
SpaceConnectionError. -
pricings.get_yaml(...)raisesPricingYamlUnavailableError, a subclass ofSpaceApiError, when Space cannot provide the original Pricing2Yaml file. -
SpaceFeatureUnsupportedError(aSpaceError, not aSpaceApiError) means the connected Space lacks a feature the call depends on;capabilitynames it with itsX-Space-Capabilitiestoken (e.g.pricing-yamlwhenget_yamlreceives JSON from a Space that ignoresformat=yaml).writtensays whether Space changed anything. -
contracts.update_contract_subscription(..., expected_contract_version=...)raisesContractChangedError, a subclass ofSpaceApiError, when the contract changed since that version (HTTP 412). It carriesuser_id,expected_contract_versionand the currentcontract_version, and the user's cached entries are invalidated. -
With
expected_contract_version,keep_billing_periodorreject_private, the same call raisesSpaceFeatureUnsupportedErrorwhen the connected Space does not implement the option (written=False, nothing sent) or applied the change without honouring it (written=True,contractset). An explicit emptyexpected_contract_versionraisesValueError. -
On the keyed writes, Space's idempotency refusals raise subclasses of
IdempotencyError(itself aSpaceApiError, withcodeandidempotency_key):IdempotencyConflictError(409 IDEMPOTENCY_CONFLICT, the key was used for a different request),IdempotencyInProgressError(409 IDEMPOTENCY_IN_PROGRESSonce the retries are spent;retry_afterin seconds; retry later with the same key) andIdempotencyKeyInvalidError(400 IDEMPOTENCY_KEY_INVALID).SpaceApiError.headersholds the response headers. -
features.revert_consumption(...)raisesSpaceFeatureUnsupportedError(capability == "atomic-usage-limits") without sending anything on a Space that cannot revert by id. -
features.verify_pricing_token(...)andPricingTokenVerifier.verify(...)raisePricingTokenVerificationErroror one of its subclasses (see Verifying pricing tokens). -
FeatureModule.evaluate(...)returns an object containingerrorwhen Space answers successfully with something that is not a usable evaluation.
Both clients raise the same exceptions.
Testing
Run tests locally:
pip install -e .[dev]
pytest
Tests never reach a live Space: the older ones stub the HTTP client, the subscription-change ones mock httpx's transport with respx and run against both the sync and the async client.
CI executes tests on every pull request for Python 3.10, 3.11, and 3.12.
Contributing
- Create a branch from
main. - Add or update tests for every behavior change.
- Open a pull request.
License
MIT License.